设计模式最佳套路3 —— 愉快地使用代理模式
何时使用代理模式
如果想为对象的某些方法做方法逻辑之外的附属功能(例如 打印出入参、处理异常、校验权限),但是又不想(或是无法)将这些功能的代码写到原有方法中,那么可以使用代理模式。
愉快地使用代理模式
▐ 背景
刚开始开发模型平台的时候,我们总是会需要一些业务逻辑之外的功能用于调试或者统计,例如这样:
public Response processXxxBiz(Request request) {
long startTime = System.currentMillis();
try {
// 业务逻辑
......
} catch (Exception ex) {
logger.error("processXxxBiz error, request={}", JSON.toJSONString(request), ex)
// 生成出错响应
......
}
long costTime = (System.currentMillis() - startTime);
// 调用完成后,记录出入参
logger.info("processXxxBiz, costTime={}ms, request={}, response={}", costTime, JSON.toJSONString(request), JSON.toJSONString(response));
}
很容易可以看出,打印出入参、记录方法耗时、捕获异常并处理 这些都是和业务没有关系的,业务方法关心的,只应该是 业务逻辑代码 才对。如果不想办法解决,长此以往,坏处就非常明显:
违反了 DRY(Don't Repeat Yourself)原则,因为每个业务方法都会包括这些业务逻辑之外的且功能类似的代码
违反了 单一职责 原则,业务逻辑代码和附加功能代码杂糅在一起,增加后续维护和扩展的复杂度,且容易导致类爆炸
所以,为了不给以后的自己添乱,我就需要一种方式,来解决上面的问题 —— 很明显,我需要的就是代理模式:原对象的方法只需关心业务逻辑,然后由代理对象来处理这些附属功能。在 Spring 中,实现代理模式的方法多种多样,下面分享一下我目前基于 Spring 实现代理模式的 “最佳套路”(如果你有更好的套路,欢迎赐教和讨论哦)~
▐ 方案
大家都听过 Spring 有两大神器 —— IoC 和 AOP。AOP 即面向切面编程(Aspect Oriented Programming):通过预编译方式(CGLib)或者运行期动态代理(JDK Proxy)来实现程序功能代理的技术。在 Spring 中使用代理模式,就是 AOP 的完美应用场景,并且使用注解来进行 AOP 操作已经成为首选,因为注解实在是又方便又好用。我们简单复习下 Spring AOP 的相关概念:
Pointcut(切点),指定在什么情况下才执行 AOP,例如方法被打上某个注解的时候
JoinPoint(连接点),程序运行中的执行点,例如一个方法的执行或是一个异常的处理;并且在 Spring AOP 中,只有方法连接点
Advice(增强),对连接点进行增强(代理):在方法调用前、调用后 或者 抛出异常时,进行额外的处理
Aspect(切面),由 Pointcut 和 Advice 组成,可理解为:要在什么情况下(Pointcut)对哪个目标(JoinPoint)做什么样的增强(Advice)
复习了 AOP 的概念之后,我们的方案也非常清晰了,对于某个代理场景:
先定义好一个注解,然后写好相应的增强处理逻辑
建立一个对应的切面,在切面中基于该注解定义切点,并绑定相应的增强处理逻辑
对匹配切点的方法(即打上该注解的方法),使用绑定的增强处理逻辑,对其进行增强
定义方法增强处理器
我们先定义出 ”代理“ 的抽象:方法增强处理器 MethodAdviceHandler 。之后我们定义的每一个注解,都绑定一个对应的 MethodAdviceHandler 的实现类,当目标方法被代理时,由对应的 MethodAdviceHandler 的实现类来处理该方法的代理访问。
/**
* 方法增强处理器
*
* @param <R> 目标方法返回值的类型
*/
public interface MethodAdviceHandler<R> {
/**
* 目标方法执行之前的判断,判断目标方法是否允许执行。默认返回 true,即 默认允许执行
*
* @param point 目标方法的连接点
* @return 返回 true 则表示允许调用目标方法;返回 false 则表示禁止调用目标方法。
* 当返回 false 时,此时会先调用 getOnForbid 方法获得被禁止执行时的返回值,然后
* 调用 onComplete 方法结束切面
*/
default boolean onBefore(ProceedingJoinPoint point) { return true; }
/**
* 禁止调用目标方法时(即 onBefore 返回 false),执行该方法获得返回值,默认返回 null
*
* @param point 目标方法的连接点
* @return 禁止调用目标方法时的返回值
*/
default R getOnForbid(ProceedingJoinPoint point) { return null; }
/**
* 目标方法抛出异常时,执行的动作
*
* @param point 目标方法的连接点
* @param e 抛出的异常
*/
void onThrow(ProceedingJoinPoint point, Throwable e);
/**
* 获得抛出异常时的返回值,默认返回 null
*
* @param point 目标方法的连接点
* @param e 抛出的异常
* @return 抛出异常时的返回值
*/
default R getOnThrow(ProceedingJoinPoint point, Throwable e) { return null; }
/**
* 目标方法完成时,执行的动作
*
* @param point 目标方法的连接点
* @param startTime 执行的开始时间
* @param permitted 目标方法是否被允许执行
* @param thrown 目标方法执行时是否抛出异常
* @param result 执行获得的结果
*/
default void onComplete(ProceedingJoinPoint point, long startTime, boolean permitted, boolean thrown, Object result) { }
}
为了方便 MethodAdviceHandler 的使用,我们定义一个抽象类,提供一些常用的方法。
public abstract class BaseMethodAdviceHandler<R> implements MethodAdviceHandler<R> {
protected final Logger logger = LoggerFactory.getLogger(this.getClass());
/**
* 抛出异常时候的默认处理
*/
@Override
public void onThrow(ProceedingJoinPoint point, Throwable e) {
String methodDesc = getMethodDesc(point);
Object[] args = point.getArgs();
logger.error("{} 执行时出错,入参={}", methodDesc, JSON.toJSONString(args, true), e);
}
/**
* 获得被代理的方法
*
* @param point 连接点
* @return 代理的方法
*/
protected Method getTargetMethod(ProceedingJoinPoint point) {
// 获得方法签名
Signature signature = point.getSignature();
// Spring AOP 只有方法连接点,所以 Signature 一定是 MethodSignature
return ((MethodSignature) signature).getMethod();
}
/**
* 获得方法描述,目标类名.方法名
*
* @param point 连接点
* @return 目标类名.执行方法名
*/
protected String getMethodDesc(ProceedingJoinPoint point) {
// 获得被代理的类
Object target = point.getTarget();
String className = target.getClass().getSimpleName();
Signature signature = point.getSignature();
String methodName = signature.getName();
return className + "." + methodName;
}
}
定义方法切面的抽象
同理,将方法切面的公共逻辑抽取出来,定义出方法切面的抽象 —— 后续每定义一个注解,对应的方法切面继承自这个抽象类就好。
/**
* 方法切面抽象类,由子类来指定切点和绑定的方法增强处理器的类型
*/
public abstract class BaseMethodAspect implements ApplicationContextAware {
/**
* 切点,通过 @Pointcut 指定相关的注解
*/
protected abstract void pointcut();
/**
* 对目标方法进行环绕增强处理,子类需通过 pointcut() 方法指定切点
*
* @param point 连接点
* @return 方法执行返回值
*/
@Around("pointcut()")
public Object advice(ProceedingJoinPoint point) {
// 获得切面绑定的方法增强处理器的类型
Class<? extends MethodAdviceHandler<?>> handlerType = getAdviceHandlerType();
// 从 Spring 上下文中获得方法增强处理器的实现 Bean
MethodAdviceHandler<?> adviceHandler = appContext.getBean(handlerType);
// 使用方法增强处理器对目标方法进行增强处理
return advice(point, adviceHandler);
}
/**
* 获得切面绑定的方法增强处理器的类型
*/
protected abstract Class<? extends MethodAdviceHandler<?>> getAdviceHandlerType();
/**
* 使用方法增强处理器增强被注解的方法
*
* @param point 连接点
* @param handler 切面处理器
* @return 方法执行返回值
*/
private Object advice(ProceedingJoinPoint point, MethodAdviceHandler<?> handler) {
// 执行之前,返回是否被允许执行
boolean permitted = handler.onBefore(point);
// 方法返回值
Object result;
// 是否抛出了异常
boolean thrown = false;
// 开始执行的时间
long startTime = System.currentTimeMillis();
// 目标方法被允许执行
if (permitted) {
try {
// 执行目标方法
result = point.proceed();
} catch (Throwable e) {
// 抛出异常
thrown = true;
// 处理异常
handler.onThrow(point, e);
// 抛出异常时的返回值
result = handler.getOnThrow(point, e);
}
}
// 目标方法被禁止执行
else {
// 禁止执行时的返回值
result = handler.getOnForbid(point);
}
// 结束
handler.onComplete(point, startTime, permitted, thrown, result);
return result;
}
private ApplicationContext appContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
appContext = applicationContext;
}
}
此时,我们基于 AOP 的代理模式小架子就已经搭好了。之所以需要这个小架子,是为了后续新增注解时,能够进行横向的扩展:每次新增一个注解(XxxAnno),只需要实现一个新的方法增强处理器(XxxHandler)和新的方法切面 (XxxAspect),而不会修改现有代码,从而完美符合 对修改关闭,对扩展开放 设计模式理念。
下面便让我们基于这个小架子,实现我们的第一个增强功能:方法调用记录(记录方法的出入参和调用时长)。
定义一个注解
/**
* 用于产生调用记录的注解,会记录下方法的出入参、调用时长
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface InvokeRecordAnno {
/**
* 调用说明
*/
String value() default "";
}
方法增强处理器的实现
@Component
public class InvokeRecordHandler extends BaseMethodAdviceHandler<Object> {
/**
* 记录方法出入参和调用时长
*/
@Override
public void onComplete(ProceedingJoinPoint point, long startTime, boolean permitted, boolean thrown, Object result) {
String methodDesc = getMethodDesc(point);
Object[] args = point.getArgs();
long costTime = System.currentTimeMillis() - startTime;
logger.warn("\n{} 执行结束,耗时={}ms,入参={}, 出参={}",
methodDesc, costTime,
JSON.toJSONString(args, true),
JSON.toJSONString(result, true));
}
@Override
protected String getMethodDesc(ProceedingJoinPoint point) {
Method targetMethod = getTargetMethod(point);
// 获得方法上的 InvokeRecordAnno
InvokeRecordAnno anno = targetMethod.getAnnotation(InvokeRecordAnno.class);
String description = anno.value();
// 如果没有指定方法说明,那么使用默认的方法说明
if (StringUtils.isBlank(description)) {
description = super.getMethodDesc(point);
}
return description;
}
}
方法切面的实现
@Aspect
@Order(1)
@Component
public class InvokeRecordAspect extends BaseMethodAspect {
/**
* 指定切点(处理打上 InvokeRecordAnno 的方法)
*/
@Override
@Pointcut("@annotation(xyz.mizhoux.experiment.proxy.anno.InvokeRecordAnno)")
protected void pointcut() { }
/**
* 指定该切面绑定的方法切面处理器为 InvokeRecordHandler
*/
@Override
protected Class<? extends MethodAspectHandler<?>> getHandlerType() {
return InvokeRecordHandler.class;
}
}
@Aspect 用来告诉 Spring 这是一个切面,然后 Spring 在启动会时扫描 @Pointcut 匹配的方法,然后对这些目标方法进行织入处理:即使用切面中打上 @Around 的方法来对目标方法进行增强处理。
@Order 是用来标记这个切面应该在哪一层,数字越小,则在越外层(越先进入,越后结束) —— 方法调用记录的切面很明显应该在大气层(小编:王者荣耀术语,即最外层),因为方法调用记录的切面应该最后结束,所以我们给一个小点的数字。
测试
现在我们就可以给开发时想要记录调用信息的方法打上这个注解,然后通过日志来观察目标方法的调用情况。老规矩,弄个 Controller :
@RestController
@RequestMapping("proxy")
public class ProxyTestController {
@GetMapping("test")
@InvokeRecordAnno("测试代理模式")
public Map<String, Object> testProxy(@RequestParam String biz,
@RequestParam String param) {
Map<String, Object> result = new HashMap<>(4);
result.put("id", 123);
result.put("nick", "之叶");
return result;
}
}
然后访问:localhost/proxy/test?biz=abc¶m=test
看出这个输出的那一刻 —— 代理成功 —— 没错,这就是程序猿最幸福的感觉。
扩展
假设我们要在目标方法抛出异常时进行处理:抛出异常时,把异常信息异步发送到邮箱或者钉钉,然后根据方法的返回值类型,返回相应的错误响应。
★ 定义相应的注解
/**
* 用于异常处理的注解
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExceptionHandleAnno { }
★ 实现方法增强处理器
@Component
public class ExceptionHandleHandler extends BaseMethodAdviceHandler<Object> {
/**
* 抛出异常时的处理
*/
@Override
public void onThrow(ProceedingJoinPoint point, Throwable e) {
super.onThrow(point, e);
// 发送异常到邮箱或者钉钉的逻辑
}
/**
* 抛出异常时的返回值
*/
@Override
public Object getOnThrow(ProceedingJoinPoint point, Throwable e) {
// 获得返回值类型
Class<?> returnType = getTargetMethod(point).getReturnType();
// 如果返回值类型是 Map 或者其子类
if (Map.class.isAssignableFrom(returnType)) {
Map<String, Object> result = new HashMap<>(4);
result.put("success", false);
result.put("message", "调用出错");
return result;
}
return null;
}
}
如果返回值的类型是个 Map,那么我们就返回调用出错情况下的对应 Map 实例(真实情况一般是返回业务系统中的 Response)。
★ 实现方法切面
@Aspect
@Order(10)
@Component
public class ExceptionHandleAspect extends BaseMethodAspect {
/**
* 指定切点(处理打上 ExceptionHandleAnno 的方法)
*/
@Override
@Pointcut("@annotation(xyz.mizhoux.experiment.proxy.anno.ExceptionHandleAnno)")
protected void pointcut() { }
/**
* 指定该切面绑定的方法切面处理器为 ExceptionHandleHandler
*/
@Override
protected Class<? extends MethodAdviceHandler<?>> getAdviceHandlerType() {
return ExceptionHandleHandler.class;
}
}
异常处理一般是非常内层的切面,所以我们将@Order 设置为 10,让 ExceptionHandleAspect 在 InvokeRecordAspect 更内层(即之后进入、之前结束),从而外层的 InvokeRecordAspect 也可以记录到抛出异常时的返回值。修改测试用的方法,加上 @ExceptionHandleAnno:
@RestController
@RequestMapping("proxy")
public class ProxyTestController {
@GetMapping("test")
@ExceptionHandleAnno
@InvokeRecordAnno("测试代理模式")
public Map<String, Object> testProxy(@RequestParam String biz,
@RequestParam String param) {
if (biz.equals("abc")) {
throw new IllegalArgumentException("非法的 biz=" + biz);
}
Map<String, Object> result = new HashMap<>(4);
result.put("id", 123);
result.put("nick", "之叶");
return result;
}
}
访问:localhost/proxy/test?biz=abc¶m=test,异常处理的切面先结束:
方法调用记录的切面后结束:
没毛病,一切是那么的自然、和谐、美好~
★ 思考
小编:可以看到抛出异常时, InvokeRecordHandler 的 onThrow 方法没有执行,为什么呢?
之叶:因为 InvokeRecordAspect 比 ExceptionHandleAspect 在更外层,外层的 InvokeRecordAspect 在执行时,执行的已经是内层的 ExceptionHandleAspect 代理过的方法,而对应的 ExceptionHandleHandler 已经把异常 “消化” 了,即 ExceptionHandleAspect 代理过的方法已经不会再抛出异常。
小编:如果我们要 限制单位时间内方法的调用次数,比如 3s 内用户只能提交表单 1 次,似乎也可以通过这个代理模式的套路来实现。
之叶:小场面。首先定义好注解(注解可以包含单位时间、最大调用次数等参数),然后在方法切面处理器的 onBefore 方法里面,使用缓存记录下单位时间内用户的提交次数,如果超出最大调用次数,返回 false,那么目标方法就不被允许调用了;然后在 getOnForbid 的方法里面,返回这种情况下的响应。
【招聘岗位】Java 工程师 、数据工程师
如果您有兴趣可将简历发至 michaelchow.zm@alibaba-inc.com 或者添加作者微信 wx_zhou_mi 进行详细咨询,欢迎来撩~
✿ 拓展阅读
作者|周密(之叶)
编辑|橙子君
出品|阿里巴巴新零售淘系技术